25장. 동시성 패턴과 도구 모음
지금까지 우리가 손에 쥔 것들:
- 22장 — 고루틴과 채널,
select,WaitGroup - 23장 — 뮤텍스, race detector, atomic
- 24장 — 락 없이 설계하는 발상들
이번 장은 그 도구들을
실제로 자주 쓰는 조합으로 묶는다.
파이프라인, fan-in/fan-out, 워커 풀,
그리고 취소/타임아웃을 다루는 context 패키지까지.
마지막엔 동시성 코드의 단골 버그인 고루틴 누수와 이를 추적하는 디버깅 팁을 다룬다.
목표:
- 자주 쓰이는 동시성 패턴 4종을 손에 익히기
context로 취소/타임아웃을 전파하는 법 익히기- 동시성 코드의 흔한 함정을 디버깅하는 감각 잡기
25.1 파이프라인 패턴
데이터를 여러 단계로 흘려보내는 구조.
[stage 1] --chan--> [stage 2] --chan--> [stage 3]
각 단계는 고루틴이고, 단계 사이를 채널이 잇는다. 한 단계는,
- 입력 채널에서 값을 받고
- 처리해서
- 출력 채널로 보낸다
비유하면 공장 컨베이어 벨트다.
예제: 숫자 생성 → 제곱 → 합계
package main
import "fmt"
// 단계 1: 1..n 을 생성
func gen(n int) <-chan int {
out := make(chan int)
go func() {
defer close(out)
for i := 1; i <= n; i++ {
out <- i
}
}()
return out
}
// 단계 2: 각 수를 제곱
func square(in <-chan int) <-chan int {
out := make(chan int)
go func() {
defer close(out)
for v := range in {
out <- v * v
}
}()
return out
}
// 단계 3: 합계 (수신 측에서 처리)
func main() {
c1 := gen(5)
c2 := square(c1)
sum := 0
for v := range c2 {
sum += v
}
fmt.Println(sum) // 1+4+9+16+25 = 55
}
장점:
- 단계별로 관심사가 또렷이 분리된다
- 각 단계의 동시성을 따로 조절할 수 있다
- 입력이 무한히 들어와도 메모리에 모두 쌓지 않는다 (스트리밍)
종료 규약
파이프라인을 안전하게 끝내는 규약 두 가지.
- 보내는 쪽이 닫는다.
- 단계가 끝나면 자신의 출력 채널을
close
- 단계가 끝나면 자신의 출력 채널을
- 닫힌 채널은
range가 자동 종료- 다음 단계가 자연스럽게 끝난다
이 규약이 지켜지면 마지막 단계까지 도미노처럼 조용히 끝난다.
중간 단계에서 일찍 끝내야 할 땐? 그건 취소(cancellation) 의 영역이다. 25.4 의
context가 이를 담당한다.
25.2 Fan-out / Fan-in
파이프라인의 한 단계가 너무 무거우면 같은 단계를 여러 고루틴이 나눠 처리하게 만들 수 있다.
- Fan-out — 하나의 입력 채널에서 여러 워커가 동시에 값을 가져간다
- Fan-in — 여러 워커의 출력 채널을 하나의 채널로 합친다
┌──→ worker 1 ──┐
입력 ──┤ worker 2 ├──→ 합치기 ──→ 다음 단계
└──→ worker 3 ──┘
Fan-out
특별한 코드가 필요 없다.
같은 채널을 여러 고루틴이 range 로 읽으면
한 값은 한 워커에게만 전달된다.
func startWorkers(in <-chan int, n int) []<-chan int {
outs := make([]<-chan int, n)
for i := 0; i < n; i++ {
out := make(chan int)
go func() {
defer close(out)
for v := range in {
out <- v * v
}
}()
outs[i] = out
}
return outs
}
각 워커는 자기만의 출력 채널을 만들고, 공용 입력 채널을 함께 읽는다.
Fan-in
여러 채널을 하나로 합치는 함수는 정해진 모양이 있다.
import "sync"
func merge(ins ...<-chan int) <-chan int {
out := make(chan int)
var wg sync.WaitGroup
wg.Add(len(ins))
for _, in := range ins {
in := in // 루프 변수 캡처 회피
go func() {
defer wg.Done()
for v := range in {
out <- v
}
}()
}
go func() {
wg.Wait()
close(out)
}()
return out
}
흐름:
- 입력 채널마다 고루틴 하나가 붙어 출력으로 옮긴다
- 모든 입력이 닫혀 끝나면
wg.Wait가 풀린다 - 그 직후
out을 닫는다
“닫는 시점을 누가 책임지는가” 가 fan-in 의 핵심이다. 별도의 고루틴이
WaitGroup을 보고 닫는 패턴이 정석이다.
합쳐서
func main() {
c1 := gen(20)
outs := startWorkers(c1, 3)
merged := merge(outs...)
for v := range merged {
fmt.Println(v)
}
}
워커 3개가 병렬로 제곱을 처리하고, 결과가 하나의 채널로 모인다. 순서는 더 이상 보장되지 않지만, 처리량은 올라간다.
25.3 워커 풀
가장 자주 쓰이는 동시성 패턴이라 따로 짚는다.
- 고정된 N 개의 워커 고루틴
- 공용 작업 채널에 작업을 던지면
- 워커들이 알아서 하나씩 가져가 처리한다
장점:
- 고루틴 수를 제어할 수 있다 (무작정 띄우다가 OOM 나는 사고 방지)
- 외부 자원(파일, DB, API) 동시 접근 수를 제한할 수 있다
정식 구현
package main
import (
"fmt"
"sync"
)
type Job struct {
ID int
Val int
}
type Result struct {
ID int
Out int
}
func worker(id int, jobs <-chan Job, results chan<- Result, wg *sync.WaitGroup) {
defer wg.Done()
for j := range jobs {
out := j.Val * j.Val // 실제 작업
results <- Result{ID: j.ID, Out: out}
fmt.Printf("worker %d 처리 %d\n", id, j.ID)
}
}
func main() {
const numWorkers = 3
const numJobs = 10
jobs := make(chan Job)
results := make(chan Result, numJobs)
var wg sync.WaitGroup
for i := 1; i <= numWorkers; i++ {
wg.Add(1)
go worker(i, jobs, results, &wg)
}
// 작업 투입
go func() {
defer close(jobs)
for i := 0; i < numJobs; i++ {
jobs <- Job{ID: i, Val: i}
}
}()
// 워커가 모두 끝나면 결과 채널을 닫는다
go func() {
wg.Wait()
close(results)
}()
for r := range results {
fmt.Printf("결과 %d -> %d\n", r.ID, r.Out)
}
}
핵심 규약 정리:
| 누가 | 무엇을 닫나 |
|---|---|
| 작업 투입 고루틴 | jobs 채널을 닫는다 |
| 별도 감시 고루틴 | wg.Wait 후 results 닫는다 |
| 워커 | 자기는 채널을 닫지 않는다 |
이 분담이 정석이다.
워커가 results 를 직접 닫으려 하면,
다른 워커가 거기 송신하다 패닉이 난다.
워커 수 정하기
- I/O 가 많은 작업: CPU 코어 수보다 많이 띄워도 된다
- 대부분 시간 동안 대기 중이라 코어를 다 안 쓴다
- CPU 가 많은 작업: 코어 수 정도가 적당하다
runtime.NumCPU()로 가져올 수 있다
“그냥 1000명 띄우자” 는 거의 항상 잘못된 답이다. 어디선가 자원이 막혀 결국 더 느려진다.
25.4 context 패키지
지금까지 본 파이프라인/워커 풀에는 한 가지가 빠져 있다.
“이제 그만, 다 멈춰.” 를 어떻게 전달할까?
타임아웃, 사용자 취소, 부모 요청 취소.
이런 신호를 호출 트리 전체에 전파해 주는 도구가
표준 라이브러리 context 패키지다.
왜 따로 도구가 필요한가
채널 하나 만들어 “취소” 라고 부르면 안 되나?
사실 그게 핵심 아이디어 그대로다.
context 는 그 아이디어를 표준화하고,
거기에 다음을 더 얹은 것이다.
- 취소 채널 + “왜 끝났는지” 사유 (
Err) - 자동 타임아웃 / 데드라인
- 호출 트리 따라 자식 컨텍스트로 전파
- 요청 범위 값(request-scoped value) 운반
기본 컨텍스트
ctx := context.Background() // 루트 (보통 main, 서버 진입점)
ctx := context.TODO() // "아직 정하지 않았다" 용 placeholder
대부분 함수의 첫 매개변수로 ctx context.Context 를 받는다.
취소 가능한 컨텍스트
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
ctx.Done()— 취소 시 닫히는 채널ctx.Err()— 왜 끝났는지 (context.Canceled또는DeadlineExceeded)cancel()— 직접 취소 (꼭 호출해 줘야 리소스 누수가 없다)
타임아웃 / 데드라인
ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
defer cancel()
// 또는
ctx, cancel := context.WithDeadline(ctx, time.Now().Add(2*time.Second))
defer cancel()
지정한 시간이 지나면 ctx.Done() 이 자동으로 닫힌다.
사용 패턴
select 와 함께 쓰는 게 정석이다.
func work(ctx context.Context, in <-chan int) error {
for {
select {
case <-ctx.Done():
return ctx.Err()
case v, ok := <-in:
if !ok {
return nil
}
process(v)
}
}
}
ctx.Done() 케이스를 첫 번째에 두는 게 관례다.
예제: HTTP 요청 타임아웃
import (
"context"
"net/http"
"time"
)
func fetch(ctx context.Context, url string) (*http.Response, error) {
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
return nil, err
}
return http.DefaultClient.Do(req)
}
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
resp, err := fetch(ctx, "https://example.com")
if err != nil {
fmt.Println("실패:", err)
return
}
defer resp.Body.Close()
// ...
}
- 2초 안에 응답이 오지 않으면 자동 취소
http.Client가ctx.Done()을 보고 연결을 끊는다
호출 트리 따라 전파
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go a(ctx) // a 안에서 다시 b(ctx) 호출
go c(ctx) // c 안에서 다시 d(ctx) 호출
부모를 취소하면 자식들도 같이 취소된다.
즉, 취소가 트리 전체에 흐른다.
이 자동 전파가 context 의 진짜 가치다.
컨텍스트 값 (간단히)
ctx := context.WithValue(parent, key, value)
v := ctx.Value(key)
요청 단위 메타데이터(사용자 ID, 요청 ID 등)를 옮길 때 쓴다.
함정: 함수 매개변수 대용으로 쓰면 안 된다. 진짜 함수 인자는 함수 인자로 받자. 컨텍스트 값은 요청 범위 메타데이터 한정.
context 권장 규약
- 함수 시그니처의 첫 번째 매개변수로
ctx context.Context - 구조체 필드로 보관하지 않는다 (수명 추적이 망가진다)
nil컨텍스트는 넘기지 않는다 (context.TODO()사용)cancel()은 항상 호출 (보통defer cancel())
25.5 그 밖의 동기화 도구
표준 라이브러리에는 자주 쓰진 않지만 알아 두면 유용한 도구가 더 있다.
sync.Once
어떤 작업을 단 한 번만 실행하고 싶을 때.
var (
once sync.Once
conn *Connection
)
func GetConn() *Connection {
once.Do(func() {
conn = openConnection()
})
return conn
}
여러 고루틴이 동시에 GetConn 을 호출해도
openConnection 은 딱 한 번만 실행된다.
다른 호출자는 첫 번째가 끝날 때까지 기다린다.
쓰임:
- 싱글톤 초기화
- 전역 캐시 워밍업
- 무거운 설정 로딩
sync.Map
동시 접근에 안전한 맵이다.
var m sync.Map
m.Store("k", 1)
v, ok := m.Load("k")
m.Delete("k")
m.Range(func(k, v interface{}) bool {
fmt.Println(k, v)
return true
})
언제 쓸까?
공식 문서가 권장하는 좁은 경우는 이렇다.
- 키 집합이 한 번 채워진 뒤 거의 변하지 않고 읽기만 많을 때
- 또는 서로 다른 고루틴이 서로 다른 키만 만질 때
그 외에는 보통 map[K]V + sync.Mutex 가 더 빠르고 읽기 쉽다.
“동시성 안전한 맵이 필요해 → sync.Map” 식의 반사적 선택은 피하자.
또한 sync.Map 은 타입 안전하지 않다 (interface{}).
제네릭 시대에는 좀 어색하게 느껴지는 API다.
sync.Cond
조건 변수(condition variable). “어떤 조건이 만족될 때까지 기다린다” 를 표현하는 저수준 도구.
var (
mu sync.Mutex
cond = sync.NewCond(&mu)
data []int
)
// 소비자
mu.Lock()
for len(data) == 0 {
cond.Wait() // mu 를 풀고 잠들어 있다가 깨면 다시 잠근다
}
v := data[0]
data = data[1:]
mu.Unlock()
// 생산자
mu.Lock()
data = append(data, 42)
cond.Signal() // 또는 cond.Broadcast()
mu.Unlock()
기능은 강력하지만 Go 에선 거의 채널로 대체 가능하다. 공식 문서도 “보통은 채널이 더 낫다” 고 안내한다.
→ 존재만 알아 두고, 처음엔 채널로 풀어 보자.
25.6 고루틴 누수
동시성 코드의 가장 흔한 사고 중 하나는 고루틴 누수다.
어떻게 새나
고루틴이 끝나지 않고 영원히 멈춰 있는 경우. 주범은 거의 항상 채널이다.
예제 1: 받는 사람이 없는 송신
func leak() {
ch := make(chan int)
go func() {
ch <- 1 // 받는 사람이 없어 영원히 막힌다
}()
// ch 를 한 번도 읽지 않는다
}
leak 이 끝나도 안의 고루틴은 끝나지 못한다.
호출할 때마다 고루틴이 하나씩 쌓인다.
예제 2: 보내는 사람이 없는 수신
func wait(ch <-chan int) {
v := <-ch // 누군가 보낼 때까지 영원히 대기
fmt.Println(v)
}
호출자가 ch 를 닫지도, 보내지도 않으면
이 고루틴은 영원히 잠든다.
예제 3: 취소 없는 무한 루프
func poll() {
for {
check()
time.Sleep(time.Second)
}
}
go poll()
종료 신호를 받을 길이 없다. 프로세스가 죽기 전엔 살아 있다.
한 줄 진단
고루틴 누수는 보통 “끝나는 조건을 안 줬다” 의 다른 이름이다.
막는 법
대원칙:
고루틴을 만들었으면 언제, 어떻게 끝나는지 같이 정해 두자.
도구별 처방:
- 채널 송신이 막힐 위험이 있다면
- 받는 사람이 항상 있도록 설계
- 또는
select + ctx.Done()으로 빠져나갈 길
- 채널 수신이 막힐 위험이 있다면
- 송신자가 다 끝나면 채널을 닫는다
select + ctx.Done()으로 빠져나갈 길
- 무한 루프 고루틴은
- 반드시
ctx context.Context를 받고 - 한 번씩
<-ctx.Done()체크
- 반드시
context 적용 예
위 예제를 누수 없는 형태로 고치면,
func poll(ctx context.Context) {
t := time.NewTicker(time.Second)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
check()
}
}
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go poll(ctx)
종료 시점에 cancel() 하면 고루틴이 깔끔히 끝난다.
25.7 동시성 디버깅 팁
마지막으로, 동시성 코드와 친해지는 데 도움이 되는 실용 팁 몇 가지.
(1) -race 는 기본으로 켠다
go test -race ./...
CI 에서 항상 켜 두자. 없을 땐 보이지 않던 버그가 켜고 나면 줄줄이 보이는 경우가 흔하다.
운영 배포에선 끄지만 (5~10배 느리다), 개발/테스트 단계의 표준 도구로 자리잡혀야 한다.
(2) 고루틴 수 모니터링
runtime.NumGoroutine() 으로 현재 고루틴 수를 알 수 있다.
import "runtime"
fmt.Println("goroutines:", runtime.NumGoroutine())
운영 서버에서는 시간에 따라 이 값이 서서히 증가만 하면 고루틴 누수다.
가벼운 패턴:
- HTTP 핸들러 시작/끝에 로그
- 요청 처리량은 비슷한데 고루틴 수만 늘면 의심
expvar등으로 메트릭에 노출
(3) pprof 의 goroutine 프로파일
언급만 짧게.
import _ "net/http/pprof"와 디버그 서버go tool pprof http://.../debug/pprof/goroutine- 현재 살아 있는 고루틴들의 스택 트레이스를 본다
“어디서 멈춰 있는지” 가 한 번에 보이므로 누수 추적의 결정타가 된다.
자세한 사용법은 26장(pprof 절)에서 다룬다.
(4) 작은 단위로 격리해서 테스트
동시성 버그는 큰 시스템에서 잡기 어렵다. 의심되는 부분을 떼어 내 짧은 테스트로 재현해 보자.
- 작은 카운터 1000번 증가
- 채널 송수신 100번 반복
go test -race -count=100 ./...
-count=N 옵션은 같은 테스트를 N번 반복 실행한다.
드물게 터지는 버그를 잡는 데 효과적이다.
(5) 가능한 한 결정론적으로
테스트는 시간에 의존하지 않게 짠다.
time.Sleep으로 “충분히 기다리겠지” 는 금물- 채널이나
WaitGroup으로 명시적 동기화 - 시간 자체가 필요하면 추상화 (
Clock인터페이스)
(6) 정리 체크리스트
코드 리뷰 때 다음을 떠올려 보자.
- 모든 고루틴에 종료 경로가 있는가?
- 채널은 누가 닫는지 명확한가?
- 락 안에서 시간이 오래 걸리는 작업을 하지 않는가?
- 락을 두 개 이상 잡는다면 순서가 정해져 있는가?
- 공유 상태 옆에
// 보호: mu같은 주석이 있는가? -
-race로 한 번 돌려 봤는가?
이 정도만 챙겨도 사고가 크게 줄어든다.
25.8 정리
이 장에서 살펴본 내용:
- 파이프라인: 단계별 채널로 데이터를 흘려보낸다
- Fan-out / Fan-in: 한 단계를 여러 워커로 병렬화하고 결과를 하나로 합친다
- 워커 풀: 고정된 N개의 워커가 작업 채널을 나눠 처리한다
context패키지로 취소/타임아웃을 호출 트리 전체에 전파한다sync.Once,sync.Map,sync.Cond는 좁은 상황에서 유용한 보조 도구다- 고루틴 누수는 “끝나는 조건이 없다” 의 다른 이름이고,
context와 채널 닫기 규약으로 막는다 - 디버깅의 첫걸음은
-race, 고루틴 수 추적,pprof다
여기까지가 Go 동시성의 큰 그림이다.
- 22장에서 도구를 익히고
- 23장에서 위험을 배우고
- 24장에서 설계로 위험을 줄이고
- 25장에서 실전 패턴으로 묶었다
다음 부에서는 시야를 다른 쪽으로 돌린다.
대용량 데이터와 메모리 효율 이야기다.
슬라이스, 포인터, 스트리밍 처리, 벤치마크와 pprof 같은
“성능을 의식한 코드” 의 기초를 본격적으로 다룬다.